Этот метод создаёт один полностью автономный HTML-файл, в который вшиты все страницы или скриншоты из папки в виде плавной бесшовной ленты.
Файл получается целиком портативным: исходные картинки можно удалить или переместить, а готовый документ переслать на смартфон или открыть на любом компьютере – интернет для просмотра не требуется.
📜 Скрипт (PowerShell)
$htmlFile = Join-Path $PWD.ProviderPath "gallery_final.html"
$extensions = @('.png', '.jpg', '.jpeg', '.webp')
$files = Get-ChildItem -File | Where-Object { $extensions -contains $_.Extension.ToLower() } |
Sort-Object { [regex]::Replace($_.Name, '\d+', { $args[0].Value.PadLeft(20, '0') }) }
if (-not $files) {
Write-Warning "Изображения не найдены в текущей папке."
exit
}
$head = @"
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=yes">
<style>
html, body { margin: 0; padding: 0; background: #111; -webkit-tap-highlight-color: transparent; }
body { overflow-x: hidden; }
body.can-scroll { overflow-x: auto; cursor: grab; }
body.grabbing { cursor: grabbing; user-select: none; }
.progress-bar { position: fixed; top: 0; left: 0; height: 3px; background: #3b82f6; width: 0%; z-index: 2000; }
.reader-wrap { width: 100%; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; }
img { width: 100%; height: auto; display: block; user-select: none; -webkit-user-drag: none; }
body.night img { filter: invert(0.9) hue-rotate(180deg) contrast(1.15); }
.toolbar { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; background: rgba(20, 20, 20, 0.85); padding: 6px 12px; border-radius: 30px; border: 1px solid rgba(255, 255, 255, 0.2); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); z-index: 1000; font-family: sans-serif; align-items: center; }
.toolbar button, .page-badge { background: none; border: none; color: #fff; font-size: 13px; font-weight: bold; cursor: pointer; padding: 6px 10px; border-radius: 15px; }
.toolbar button:hover { background: rgba(255, 255, 255, 0.15); }
.page-badge { cursor: default; opacity: 0.8; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div class="progress-bar" id="pBar"></div>
<div class="reader-wrap" id="wrap">
"@
$tail = @"
</div>
<div class="toolbar">
<span class="page-badge" id="pBadge">1 / $($files.Count)</span>
<button onclick="stepZoom()" id="zBtn">🔍 1.0×</button>
<button onclick="toggleNight()" id="nBtn">🌙</button>
</div>
<script>
const wrap = document.getElementById('wrap');
const zBtn = document.getElementById('zBtn');
const nBtn = document.getElementById('nBtn');
const pBar = document.getElementById('pBar');
const pBadge = document.getElementById('pBadge');
const imgs = document.querySelectorAll('img');
const total = imgs.length;
const scales = [1.0, 1.3, 1.6];
let curIdx = 0;
let baseDist = 0;
let initialW = 100;
let currentPage = 1;
function applyScale(s) {
s = Math.min(Math.max(s, 1.0), 2.5);
wrap.style.width = (s * 100) + '%';
if (s > 1.0) {
wrap.style.maxWidth = 'none';
document.body.classList.add('can-scroll');
const extraW = wrap.offsetWidth - window.innerWidth;
if (extraW > 0) {
window.scrollTo({ left: extraW / 2, top: window.scrollY });
}
} else {
wrap.style.maxWidth = '1400px';
document.body.classList.remove('can-scroll');
window.scrollTo({ left: 0, top: window.scrollY });
}
zBtn.textContent = '🔍 ' + s.toFixed(1) + '×';
}
function stepZoom() {
curIdx = (curIdx + 1) % scales.length;
applyScale(scales[curIdx]);
}
function toggleNight(forceState) {
const isN = typeof forceState === 'boolean' ? document.body.classList.toggle('night', forceState) : document.body.classList.toggle('night');
nBtn.textContent = isN ? '☀️' : '🌙';
localStorage.setItem('night_' + location.pathname, isN ? '1' : '0');
}
let isDown = false;
let startX = 0;
let scrollStart = 0;
window.addEventListener('mousedown', (e) => {
if (e.button !== 0 || e.target.closest('.toolbar') || wrap.offsetWidth <= window.innerWidth) return;
isDown = true;
document.body.classList.add('grabbing');
startX = e.clientX;
scrollStart = window.scrollX;
});
const stopDrag = () => {
if (isDown) {
isDown = false;
document.body.classList.remove('grabbing');
}
};
window.addEventListener('mouseup', stopDrag);
window.addEventListener('mouseleave', stopDrag);
window.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
window.scrollTo(scrollStart - (e.clientX - startX), window.scrollY);
});
document.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
baseDist = Math.hypot(e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY);
initialW = parseFloat(wrap.style.width) || 100;
}
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (e.touches.length === 2 && baseDist > 0) {
if (e.cancelable) e.preventDefault();
const dist = Math.hypot(e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY);
applyScale((initialW * (dist / baseDist)) / 100);
}
}, { passive: false });
document.addEventListener('touchend', (e) => {
if (e.touches.length < 2) baseDist = 0;
});
const observer = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
const idx = Array.prototype.indexOf.call(imgs, e.target) + 1;
currentPage = idx;
pBadge.textContent = idx + ' / ' + total;
localStorage.setItem('page_' + location.pathname, idx);
}
});
}, { rootMargin: '-40% 0px -40% 0px', threshold: 0 });
imgs.forEach(img => observer.observe(img));
window.addEventListener('scroll', () => {
const maxH = document.documentElement.scrollHeight - window.innerHeight;
pBar.style.width = (maxH > 0 ? (window.scrollY / maxH) * 100 : 0) + '%';
});
window.addEventListener('DOMContentLoaded', () => {
if (localStorage.getItem('night_' + location.pathname) === '1') {
toggleNight(true);
}
const savedPage = parseInt(localStorage.getItem('page_' + location.pathname), 10);
if (savedPage && imgs[savedPage - 1]) {
setTimeout(() => {
imgs[savedPage - 1].scrollIntoView();
}, 50);
}
});
</script>
</body>
</html>
"@
$writer = [System.IO.StreamWriter]::new($htmlFile, $false, [System.Text.Encoding]::UTF8)
try {
$writer.Write($head)
foreach ($file in $files) {
$bytes = [System.IO.File]::ReadAllBytes($file.FullName)
$base = [System.Convert]::ToBase64String($bytes)
$mime = switch ($file.Extension.ToLower()) {
'.png' { 'image/png' }
'.webp' { 'image/webp' }
default { 'image/jpeg' }
}
$writer.Write("<img src='data:$mime;base64,$base' loading='lazy'>")
}
$writer.Write($tail)
} finally {
$writer.Dispose()
}
Write-Host "Готово! Собрано файлов: $($files.Count)" -ForegroundColor Green
Start-Process $htmlFile
⚙️ Разбор команд скрипта
$htmlFile = Join-Path $PWD.ProviderPath "gallery_final.html"– надёжно формирует путь к файлу в текущей папке, исключая ошибки провайдера путей PowerShell.$extensions = @(...)– список расширений файлов, которые скрипт отберёт для создания ленты.Get-ChildItem -File | Where-Object ...– находит все подходящие изображения в папке, игнорируя посторонние файлы и подпапки.Sort-Object { [regex]::Replace(...) }– натуральная сортировка имён. Выравнивает числа нулями при сравнении, благодаря чему страница2встаёт перед10, а9– перед100.[System.IO.StreamWriter]::new(...)– открывает прямой файловый поток с кодировкой UTF-8 для непрерывной записи без перегрузки оперативной памяти.$head = @"..."@– HTML-каркас: тёмный фон, адаптивность ленты, панель инструментов, стили индикатора чтения и ночного режима.[System.IO.File]::ReadAllBytesиToBase64String– считывают каждый файл и кодируют его прямо в текст Base64, делая документ полностью автономным.switch ($file.Extension.ToLower())– определяет правильный MIME-тип (image/png,image/webpилиimage/jpeg) для корректного отображения в браузере.loading='lazy'– активирует встроенную ленивую загрузку: браузер обрабатывает изображения только по мере прокрутки, экономя память устройства.$tail = @"..."@– движок читалки: ступенчатый зум с поддержкой щипка двумя пальцами, ночной режим, счётчик страниц черезIntersectionObserverи запоминание позиции вlocalStorage.$writer.Write(...)и$writer.Dispose()– последовательно записывают структуру в файл и корректно освобождают системные ресурсы.Start-Process $htmlFile– автоматически открывает готовую читалку в браузере по умолчанию
🚀 Как пользоваться
- Откройте папку со своими изображениями.
- Откройте консоль в этой папке (любым удобным способом):
- Кликните вверху по адресной строке Проводника, введите
powershellи нажмите Enter. - Либо кликните правой кнопкой мыши на пустом месте внутри папки ➔ «Открыть в Терминале».
- Кликните вверху по адресной строке Проводника, введите
- Вставьте скопированный скрипт в окно консоли и нажмите Enter.
- Браузер сразу откроет созданный файл
gallery_final.html.
🧩Особенности и преимущества
- Полная автономность (Base64): все страницы вшиты в один HTML-файл. Документ работает без интернета и не теряет картинки при переносе.
- Память чтения (
localStorage): браузер автоматически запоминает место остановки и восстанавливает скролл при повторном открытии. - Счётчик страниц и прогресс: тонкий индикатор чтения вверху экрана и счётчик текущей страницы в реальном времени.
- Ночной режим (🌙): мягкая инверсия страниц для комфортного чтения сканов с белым фоном в темноте.
- Удобный зум: переключение ступеней масштаба (
1.0×,1.3×,1.6×) по кнопке и свободное увеличение жестом двух пальцев. - Натуральная сортировка (
[regex]::Replace): номера страниц идут строго по порядку (2встанет перед10, а9перед100). - Экономия ресурсов: потоковая запись через
StreamWriterбез расхода лишней RAM и ленивая подгрузка кадров по мере прокрутки.